Skip to content

Steam Controller: Phase 3a — IMU quaternion to visualizer + Test Report multi-interface fix#11

Merged
petegordon merged 1 commit into
mainfrom
feat/steam-controller-imu-quaternion
May 24, 2026
Merged

Steam Controller: Phase 3a — IMU quaternion to visualizer + Test Report multi-interface fix#11
petegordon merged 1 commit into
mainfrom
feat/steam-controller-imu-quaternion

Conversation

@petegordon

Copy link
Copy Markdown
Member

Wires the Steam Controller's IMU into the visualizer's body rotation. Pitch + roll track physical motion correctly; yaw is captured but ~10× weaker (typical for IMUs without a magnetometer) and not visually compelling — left as a known limitation.

Also fixes the Test Report wizard's multi-interface listener gap that was causing zero-report captures on the Puck.

Summary

  • Driver parses 4× int16 LE at WebHID bytes 31-38 as quaternion components and returns parsed.orientation. The first component overlaps the firmware's uint32 timestamp at bytes 29-32, but Three.js's normalize smooths it enough that pitch + roll visualize correctly with the right axis transform.
  • App fast-path: when a driver returns parsed.orientation, writes it directly into gyroFusion.orientation, bypassing the rate-based SensorFusion pipeline. All existing consumers (overlay body rotation, gimbal widget, R/P/Y readout, gyro HUD) pick it up unchanged.
  • Reference-quaternion capture: first valid orientation becomes the rest reference; subsequent frames are emitted as delta = current * ref⁻¹. L3+R3 (existing calibrate combo) recaptures.
  • Runtime axis-transform switcher in DevTools: window.setSteamQuatTransform(mode) cycles between 10+ candidate basis transforms. Default 'yzp-xy-alt' = (z, x, y), field-tested as best of options.
  • Test Report wizard now fans out the inputreport listener across sibling HID handles with the same vid:pid — was previously listening on just the one handle the picker returned, which is the wrong interface on the Puck.
  • UI: axis readout (P/R/Y degrees) moved from top-right to bottom-center (was overlapping the Puck status banner). HUD Position setting renamed to "Roll HUD Position" since it only controls the gyro arc widget.

Verified via Test Report variance analysis on real hardware

  • Pitch movement → bytes 35-36 stddev 353× baseline
  • Roll movement → bytes 33-34 stddev 467× baseline
  • Yaw movement → all components weakly (max 59× baseline; absolute stddev 10× smaller than pitch/roll)

What's NOT done / known issues

  • Yaw is not visually convincing on this firmware. The signal is captured but weak. Could be firmware-side filtering (typical without a magnetometer) or could be that we have the wrong byte interpretation. The Test Report capture infrastructure is in place for future investigation.
  • IMU encoding interpretation is best-guess. SteamlessController's documented offsets overlap the timestamp on this firmware. Treating the 6 bytes at 33-38 as a compressed quaternion (X,Y,Z + derived W) didn't produce working motion either — possibly the encoding is Euler angles in radians, or some other scheme. Wrapped as "good enough" rather than "definitively correct."
  • Trackpad widgets in HUD (cross-tracked in Scaling: resize Button HUD popout + resize 3D controller model #7) and rumble / LED output reports still pending — separate work.

Commits

  • 8a60653 Phase 3a IMU quaternion to visualizer + UI fixes

Test plan

  • Steam Controller plugged in via USB-C or Puck: 3D model rotates as controller is physically pitched / rolled
  • L3+R3 recaptures the reference position (controller's current pose becomes new "rest")
  • Test Report wizard captures 1300+ STATE reports per step on the Puck (was 0 before)
  • P/R/Y readout positioned at bottom-center, no overlap with Puck status banner
  • HUD Position setting label clearly says "Roll HUD Position"

Refs #8.

Adds quaternion-orientation plumbing for the Steam Controller and the
supporting infrastructure (Test Report multi-interface fan-out, runtime
axis-transform switcher). Pitch + roll track physical motion correctly
in the on-screen model when using the field-tested 'yzp-xy-alt'
transform. Yaw signal is captured but ~10× weaker than pitch/roll
(typical for IMUs without a magnetometer), and visually not convincing
— left as a known limitation for future iteration.

Driver (packages/core/src/drivers/steam-controller-driver.js):
- Parses 4× int16 LE at bytes 31-38 as quaternion components, returns
  parsed.orientation = {x, y, z, w}. The first component (bytes 31-32)
  is actually the high half of the device's uint32 timestamp on this
  firmware — SteamlessController's docs claim the quaternion lives at
  data[31-38] but variance analysis of a real Test Report capture
  shows bytes 29-32 are a ~1MHz uint32 timestamp. Three.js's normalize
  flattens the timestamp byte's effect enough that pitch + roll
  visualize correctly. Better encoding TBD — possibly Euler angles in
  radians, didn't lead to working motion when tried.
- emitsRawGyro = false (already) flag tells app.js to skip the
  rate-based calibration UX since the driver emits orientation, not
  rates.

App (apps/overlay/src/js/app.js):
- Quaternion fast-path: when parsed.orientation is present, writes
  directly into gyroFusion.orientation (bypasses SensorFusion). All
  downstream consumers (overlay body rotation, gimbal widget, R/P/Y
  readout, gyro HUD) pick it up unchanged.
- Reference-quaternion capture: first valid parsed.orientation
  becomes the rest reference; every subsequent frame is emitted as
  delta = current * ref⁻¹. L3+R3 (existing calibrate combo)
  recaptures the reference. Hooks into existing showCalibHint UX so
  user sees feedback when they recenter.
- Runtime axis-transform switcher: window.setSteamQuatTransform(mode)
  cycles between 10+ candidate IMU-body→visualizer-world basis
  transforms. Default 'yzp-xy-alt' = (z, x, y), found via empirical
  iteration. Other transforms in the registry for future tuning.

Test Report wizard (apps/overlay/src/js/test-report.js):
- Multi-interface input fan-out: previously the wizard listened on
  one Puck HID handle (whichever the picker returned) — that's the
  wrong interface for the Puck, so every step recorded 0 reports.
  Now mirrors the gyro pipeline's fan-out: enumerates approved
  siblings with same vid:pid, opens + attaches the recorder to each.
  Single-interface pads (DualSense filtered by usagePage) are no-op.

UI:
- Axis readout (P/R/Y degrees) moved from top-right to bottom-center.
  Was overlapping the Puck status banner.
- HUD Position setting renamed → "Roll HUD Position" since it only
  controls the gyro/roll arc widget, not the axis readout.

Verified via Test Report variance analysis on real hardware:
- Pitch movement primarily varies bytes 35-36 (353× baseline stddev)
- Roll movement primarily varies bytes 33-34 (467× baseline)
- Yaw movement varies all components weakly (max 59× baseline, but
  absolute stddev 10× smaller than pitch/roll)

Phase 3a is sized as "best-with-current-understanding" — the right
IMU encoding remains unclear and the test infrastructure to iterate
further is in place for a future contributor.
@petegordon petegordon merged commit bfbac6f into main May 24, 2026
petegordon added a commit that referenced this pull request May 24, 2026
Replaces the quaternion-output path landed in #11 with the standard
rate-based encoding used by DualSense. Per-axis Test Report variance
analysis revealed the actual 2026 firmware layout differs from what
SteamlessController documents:

  bytes 29-32 = uint32 LE timestamp (~1 MHz clock)
  bytes 33-38 = 3-axis accelerometer (int16 LE, ±2g full scale)
  bytes 39-44 = 3-axis gyroscope    (int16 LE, ±2000 dps)
  bytes 45+   = always-zero padding

SteamlessController claimed a 4-int16 quaternion at data[31-38] but
that overlaps the timestamp on this firmware. With the corrected
layout, both sensors look exactly like a DualSense:
  - gyro: 0 ± 0.2 dps across all axes at rest (perfect zero-bias)
  - accel: gravity ≈ +1g on body-Z when flat face-up

So we flow them straight into the existing SensorFusion pipeline —
calibration, drift correction, orientation integration, the L3+R3
recalibrate combo all reuse the proven DualSense code path. No
quaternion fast-path, no setSteamQuatTransform DevTools switcher, no
reference-quaternion capture.

Body-to-visualizer frame remap is in the driver:
  - Swap Y ↔ Z on both gyro and accel: moves gravity from body-Z to
    body-Y to match Three.js's "Y up" convention used by DualSense.
  - Negate Z on both: flips roll direction to match visualizer roll.

Field-verified on a 2026 Steam Controller — pitch, roll, AND yaw all
track physical motion correctly. The IMU yaw signal is fully usable
once read from the right bytes (~34 dps stddev during yaw step, same
magnitude as pitch/roll).

Known minor issue: ~6-7° residual pitch drift settling on initial
calibration. Acceptable for now; can be tuned later via the
SensorFusion calibration thresholds.

App.js cleanup removes the quaternion-direct scaffolding from #11:
  - _steamRawQuat / _steamDelta / _steamRefQuat / recalibrateSteamReference
  - STEAM_QUAT_TRANSFORMS registry + window.setSteamQuatTransform
  - parsed.orientation fast-path
  - calibration-skip guard (we DO calibrate now, same as DualSense)

Profile change: gyroTransform stays identity (driver does the remap
internally — same pattern as Switch Pro). Comment clarifies the field
is informational on this profile.

Closes the IMU-encoding ambiguity left open in #11.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant